API Design Patterns — 9장 사용자 메서드 딥다이브 학습 노트

출처: API Design Patterns (JJ Geewax) | 참고: https://google.aip.dev/


9장에서 다루는 내용

  • 표준 메서드가 모든 상황에 대응하지 못하는 이유 — update로 이메일 전송을 처리하면 왜 문제인지
  • 부수 효과가 필연적인 상황에서의 사용자 메서드 사용 — SMTP 통신, 알림 발송 등 표준 메서드로 할 수 없는 동작
  • 계산 집중적 API 메서드에 무상태 사용자 메서드를 도입하는 방법 — 번역, 유효성 검증 등 저장 없이 결과만 반환
  • 사용자 메서드의 목표(리소스와 컬렉션) — 단일 리소스 대상 vs 컬렉션 대상 vs 무상태 메서드

전체 흐름도

[문제 인식]                       [해결 도구]                      [구현 세부사항]
    |                                 |                               |
    v                                 v                               v
+---------------------+     +---------------------+     +---------------------------+
| 표준 메서드로는      |     |                     |     | 9.3 구현                   |
| 모든 동작을 표현    |---->|   사용자 메서드       |---->|   POST + 콜론(:) 구분자   |
| 할 수 없음          |     |  (Custom Method)     |     +---------------------------+
+---------------------+     |                     |              |
                             |  = 표준 메서드의    |     +---------------------------+
+---------------------+     |    범위를 넘어서는   |---->| 9.3.1 부수 효과           |
| update로 상태 전환  |---->|    API 호출          |     | (이메일 발송, 외부 통신)   |
| → 의미 왜곡 + 부수  |     |                     |     +---------------------------+
| 효과 문제           |     +---------------------+              |
+---------------------+       |        |        |      +---------------------------+
                               v        v        v      | 9.3.2 리소스 vs 컬렉션     |
                          +--------+ +------+ +------+  | (대상에 따라 URL 구조 결정)|
                          | 리소스 | |컬렉션| |무상태|  +---------------------------+
                          | 대상   | | 대상 | | 메서드|           |
                          +--------+ +------+ +------+  +---------------------------+
                               |         |        |     | 9.3.3 무상태 사용자 메서드  |
                               v         v        v     | (GDPR, 번역 등 저장 불필요)|
                          +----------------------------+ +---------------------------+
                          | 9.3.4 최종 API 정의         |          |
                          | (표준+사용자 메서드 조합)   | +---------------------------+
                          +----------------------------+ | 9.4 트레이드오프           |
                                                         | (남용 주의, 청테이프 금지) |
                                                         +---------------------------+

선수 지식 체크리스트

  • [ ] HTTP 메서드 기본 이해 (GET, POST, PUT, PATCH, DELETE)
  • [ ] REST API 기본 개념 (리소스, 엔드포인트, 컬렉션)
  • [ ] 7장: 표준 메서드 (CRUD — Create, Read, Update, Delete)
  • [ ] 8장: 부분 업데이트와 부분 취득 (Field Mask)

핵심 키워드

키워드 의미
사용자 메서드 (Custom Method) 표준 CRUD 메서드로 표현할 수 없는 특수한 동작을 위한 API 메서드. POST 사용, 콜론(:)으로 동작명 구분
표준 메서드 (Standard Method) CRUD(Create, Read, Update, Delete, List)에 해당하는 기본 API 메서드. 리소스 데이터 접근/조작에 한정
부수 효과 (Side Effect) API 호출 시 주 작업 외에 추가로 발생하는 동작. 예: 이메일 전송, 알림 발송, 외부 시스템 통신
무상태 메서드 (Stateless Method) 서버에 데이터를 저장하지 않고 입력만으로 결과를 반환하는 메서드. 예: 번역, 유효성 검증
콜론 구분자 (:) 사용자 메서드 URL에서 리소스 경로와 동작명을 분리하는 특수문자. /rockets/123:launch
리소스 대상 특정 단일 리소스에 동작하는 사용자 메서드. 예: SendEmail({id: 1})
컬렉션 대상 리소스 묶음(컬렉션) 전체에 동작하는 사용자 메서드. 예: ExportEmails
RPC (Remote Procedure Call) 원격 프로시저 호출. 사용자 메서드는 본질적으로 RPC 스타일의 호출
하류 의존성 (Downstream Dependency) API 호출이 내부적으로 의존하는 외부 시스템 (SMTP, DB, 3rd party 등)
앵커 리소스 (Anchor Resource) 무상태 메서드를 연결하는 부모 리소스. 비용 추적, 권한 관리 등에 활용
멱등성 (Idempotency) 같은 요청을 여러 번 보내도 결과가 동일한 성질. 안전한 사용자 메서드는 GET 사용 가능

9.1 서론 — 표준 메서드의 한계

핵심 질문: "왜 사용자 메서드가 필요할까?"

표준 메서드(Create, Get, Update, Delete, List)는 API의 기본 구성 요소이다. 하지만 실무에서는 이 다섯 가지로 모든 동작을 깔끔하게 표현할 수 없는 상황이 자주 발생한다.

비유: 기본 공구 세트

표준 메서드 = 드라이버, 망치, 펜치, 톱, 줄자 (기본 5종 공구) 사용자 메서드 = 전동 드릴, 열풍기, 레이저 레벨 (특수 도구)

기본 공구로 대부분 할 수 있지만, 콘크리트 벽에 구멍을 뚫으려면 전동 드릴이 필요하다. 무리하게 드라이버로 하면? → 결과가 엉망이 된다.

9.1.1 표준 메서드를 쓰면 안 될까?

표준 메서드로 기술적으로 구현 가능하더라도, 그렇게 하면 API가 예측 불가능하고, 혼란스럽고, 복잡해진다.

이메일 전송 예제 — 왜 update가 안 되는가

표준 메서드(update)로 이메일을 "보냄" 처리하는 잘못된 방식:

const email = GetEmail({id: 'email id here'});   // 상태: "초안"
UpdateEmail({id: email.id, state: 'sent'});       // 상태 → "보냄"으로 변경
                                                   // → 실제로 이메일이 전송됨!

3가지 근본적 문제:

문제 1: 의미 왜곡
┌──────────────────────────────────────────────┐
│  "상태 필드를 'sent'로 바꾸세요"             │
│                    ≠                          │
│  "이 이메일을 실제로 수신자에게 보내세요"     │
│                                               │
│  상태 변경은 단순 데이터 수정이 아니라         │
│  완전히 다른 동작(이메일 발송)을 의미한다     │
└──────────────────────────────────────────────┘

문제 2: 부수 효과 (Side Effect)
┌──────────────────────────────────────────────┐
│  update 표준 메서드 → "데이터만 갱신해야 함"  │
│  그런데 실제로는:                              │
│    1. DB에 상태 저장 (스토리지 통신)           │
│    2. SMTP 서버로 이메일 전송 (외부 통신)      │
│    3. 수신자 서비스에 알림 (추가 통신)         │
│  → 단순 업데이트가 아니라 복잡한 오케스트레이션│
└──────────────────────────────────────────────┘

문제 3: 예측 불가능
┌──────────────────────────────────────────────┐
│  사용자가 단지 이메일 내용을 수정하고 싶은데   │
│  실수로 state를 건드리면?                     │
│  → 의도치 않게 이메일이 발송될 수 있다!       │
│                                               │
│  또한 이메일 전송에 시간이 걸리면              │
│  update 응답이 지연 → "왜 update가 느리지?"   │
└──────────────────────────────────────────────┘

update를 통한 상태 변경의 시스템 흐름

   사용자           API 서버        스토리지        SMTP 서버
     |                 |               |               |
     |  updateEmail()  |               |               |
     |────────────────>|               |               |
     |                 |   save()      |               |
     |                 |──────────────>|               |
     |                 |      OK       |               |
     |                 |<──────────────|               |
     |                 |               |               |
     |                 |          sendEmail() (추정)   |
     |                 |──────────────────────────────>|
     |                 |               |          전송 시도...
     |                 |               |          (실패할 수도?)
     |   응답 반환     |               |               |
     |<────────────────|               |               |
     |  (언제 올지 모름)|               |               |

문제: update 하나에 2개 시스템과 통신해야 하고,
      각 단계마다 실패 가능성이 있다!

리소스 재설계로 해결할 수 있을까?

방법: EmailDraft + Email 분리

abstract class EmailApi {
    @post("/{parent=users/*}/emailDrafts")
    CreateEmailDraft(req): EmailDraft;    // 가변성 — 수정 가능

    @post("/{parent=users/*}/emails")
    CreateEmail(req): Email;              // 불변성 — 생성 = 전송
}

interface EmailDraft { id, subject, ... }  // 편집 가능한 초안
interface Email { id, content: EmailDraft } // 보낸 이메일 (변경 불가)

이렇게 리소스를 나눌 수 있지만, 표준 메서드에만 의존하게 강제하는 것은 일종의 강박이다. API의 목표는 표현적이고, 단순하고, 예측 가능한 인터페이스를 만드는 것이다.

핵심 원칙: 동작을 표준 메서드 구조에 억지로 끼워 맞추지 말고, 별도의 사용자 메서드를 만들어 필요한 기능만 수행하도록 분리하라.


9.2 개요 — 사용자 메서드란?

정의

사용자 메서드(Custom Method) = 표준 메서드의 범위에서 다룰 수 없는 API 호출

비유: 레스토랑 메뉴

표준 메서드 = 정규 메뉴 (스테이크, 파스타, 샐러드, 수프, 디저트)
  → 누구나 이름만 보고 무엇인지 알 수 있다
  → 대신 메뉴에 없는 요리는 주문할 수 없다

사용자 메서드 = 셰프 특별 메뉴 (오늘의 요리, 계절 한정 메뉴)
  → 이름만으로는 내용을 바로 알기 어렵다
  → 대신 정규 메뉴로 불가능한 특별한 요리를 제공한다
  → 레스토랑(API) 전체에서 일관된 스타일을 유지해야 한다

표준 메서드 vs 사용자 메서드 비교

구분 표준 메서드 사용자 메서드
목적 CRUD (데이터 접근/조작) 비표준 동작 (상태 변환, 외부 통신 등)
규칙/제약 많음 (이름, HTTP 메서드, 동작 범위 정해짐) 적음 (자유롭게 설계 가능)
예측 가능성 높음 (이름만 보면 동작 추측 가능) 낮음 (문서를 봐야 이해 가능)
부수 효과 금지 (정의된 동작만 수행) 허용 (이메일 발송, 알림 등 자유)
HTTP 메서드 동작에 맞는 메서드 사용 (GET, POST, PATCH 등) 거의 항상 POST 사용
URL 패턴 GET /users/1/emails/2 POST /users/1/emails/2:send

사용자 메서드가 필요한 대표적 상황

1. 상태 전환 (State Transition)
   이메일 "초안" → "보냄" → "보관"
   주문 "대기" → "배송중" → "완료"
   서류 "작성중" → "승인" → "반려"

2. 부수 효과가 필요한 동작
   이메일 전송 (SMTP 서버 통신)
   결제 처리 (PG사 통신)
   푸시 알림 발송

3. 계산/변환 작업
   텍스트 번역 (Google Translate)
   이메일 주소 유효성 검증
   이미지 리사이즈

4. 일괄 처리 (Batch)
   전체 이메일 내보내기 (Export)
   다수 리소스 한번에 보관 (Archive)

코드로 보는 사용자 메서드 예시 — 로켓 발사

abstract class RocketApi {
    // 사용자 메서드: POST 사용, 콜론(:)으로 동작 구분
    @post("/{id=rockets/*}:launch")
    LaunchRocket(req: LaunchRocketRequest): Rocket;
}

interface Rocket {
    id: string;
    // ...
}

interface LaunchRocketRequest {
    id: string;      // 어떤 로켓을 발사할지
}

이름 짓기 규칙: <동사><명사> 형태 - LaunchRocket (O) — 동사(Launch) + 명사(Rocket) - ArchiveDocument (O) - CreateRocketForMars (X) — with, for 같은 전치사 금지! - DoLaunch (X) — 불명확한 동사 사용 금지


9.3 구현 — 실제로 어떻게 만드는가

HTTP 요청 구성 요소

POST  /  rockets / 1234567  :  launch
 │       └──────────────┘     └──────┘
 │           리소스 경로         동작명
 │
 HTTP 메서드
 (거의 항상 POST)

왜 POST를 사용하는가?

HTTP 메서드별 의미:
┌────────┬──────────────────────────────────┐
│ GET    │ 데이터 조회 (부수 효과 없어야 함) │
│ POST   │ "이 데이터로 뭔가 처리해 주세요"  │ ← 가장 유연
│ PUT    │ 전체 교체                          │
│ PATCH  │ 부분 수정                          │
│ DELETE │ 삭제                               │
└────────┴──────────────────────────────────┘

사용자 메서드는 다양한 동작을 수행하므로
가장 범용적인 POST가 적합하다.

예외: 멱등성이 보장되고 안전한 읽기 전용 메서드 → GET 사용 가능
      (싱글톤 서브리소스가 더 적합할 수도 있음 — 12장 참고)

콜론(:) 구분자의 역할

슬래시(/) vs 콜론(:) 비교:

POST /rockets/1234567/launch    ← 슬래시 사용 (잘못된 방식)
  → "launch"가 하위 리소스처럼 보임
  → /rockets/1234567/launch/... 추가 경로가 있는 것처럼 혼동

POST /rockets/1234567:launch    ← 콜론 사용 (올바른 방식)
  → 리소스(1234567)가 끝나고 동작(launch)이 시작됨을 명확히 표시
  → URL은 다소 이상해 보이지만, 의미가 명확하다

비유: 문장 부호

"서울역 출발" vs "서울역: 출발" 콜론이 "여기부터 새로운 내용(동작)" 임을 알려주는 역할

Flask/Python 실무 예제 — 이메일 전송 API

from flask import Flask, request, jsonify

app = Flask(__name__)

# 가상 데이터베이스
emails_db = {
    "1": {"id": "1", "subject": "회의 안내", "content": "...", "state": "draft"},
    "2": {"id": "2", "subject": "보고서", "content": "...", "state": "draft"},
}


# ─── 표준 메서드들 ───

@app.route("/users/<user_id>/emails", methods=["POST"])
def create_email(user_id):
    """표준 메서드: 이메일 생성 (초안 상태)"""
    data = request.json
    email_id = str(len(emails_db) + 1)
    emails_db[email_id] = {
        "id": email_id,
        "subject": data.get("subject", ""),
        "content": data.get("content", ""),
        "state": "draft",      # 항상 초안 상태로 생성
    }
    return jsonify(emails_db[email_id]), 201


@app.route("/users/<user_id>/emails/<email_id>", methods=["GET"])
def get_email(user_id, email_id):
    """표준 메서드: 이메일 조회"""
    email = emails_db.get(email_id)
    if not email:
        return jsonify({"error": "이메일을 찾을 수 없습니다"}), 404
    return jsonify(email)


@app.route("/users/<user_id>/emails/<email_id>", methods=["PATCH"])
def update_email(user_id, email_id):
    """표준 메서드: 이메일 수정 (제목, 내용만 — 상태 변경 불가!)"""
    email = emails_db.get(email_id)
    if not email:
        return jsonify({"error": "이메일을 찾을 수 없습니다"}), 404
    if email["state"] != "draft":
        return jsonify({"error": "보낸 이메일은 수정할 수 없습니다"}), 400

    data = request.json
    # 중요: state 필드는 update로 변경할 수 없다!
    if "subject" in data:
        email["subject"] = data["subject"]
    if "content" in data:
        email["content"] = data["content"]
    return jsonify(email)


# ─── 사용자 메서드들 ───

@app.route("/users/<user_id>/emails/<email_id>:send", methods=["POST"])
def send_email(user_id, email_id):
    """사용자 메서드: 이메일 전송

    부수 효과 있음:
    1. SMTP 서버를 통해 실제 이메일 전송
    2. 상태를 'sent'로 변경
    """
    email = emails_db.get(email_id)
    if not email:
        return jsonify({"error": "이메일을 찾을 수 없습니다"}), 404
    if email["state"] == "sent":
        return jsonify({"error": "이미 전송된 이메일입니다"}), 400

    # 1단계: SMTP 서버로 전송 (부수 효과!)
    success = _send_via_smtp(email)
    if not success:
        return jsonify({"error": "이메일 전송 실패"}), 500

    # 2단계: 상태 업데이트
    email["state"] = "sent"
    return jsonify(email)


@app.route("/users/<user_id>/emails/<email_id>:unsend", methods=["POST"])
def unsend_email(user_id, email_id):
    """사용자 메서드: 이메일 전송 취소 (지연 전송 중일 때)"""
    email = emails_db.get(email_id)
    if not email or email["state"] != "sending":
        return jsonify({"error": "취소할 수 없는 상태입니다"}), 400

    email["state"] = "draft"
    _cancel_smtp_queue(email_id)   # SMTP 큐에서 제거
    return jsonify(email)


@app.route("/users/<user_id>/emails/<email_id>:undelete", methods=["POST"])
def undelete_email(user_id, email_id):
    """사용자 메서드: 삭제된 이메일 복구"""
    email = emails_db.get(email_id)
    if not email:
        return jsonify({"error": "이메일을 찾을 수 없습니다"}), 404

    email["deleted"] = False
    return jsonify(email)


@app.route("/users/<user_id>/emails:export", methods=["POST"])
def export_emails(user_id):
    """사용자 메서드 (컬렉션 대상): 전체 이메일 내보내기"""
    # 컬렉션에 대한 사용자 메서드 → 여러 리소스를 한번에 처리
    export_data = list(emails_db.values())
    # 원격 스토리지에 파일로 내보내기 (부수 효과)
    export_url = _export_to_storage(export_data)
    return jsonify({"exportUrl": export_url, "emailCount": len(export_data)})


def _send_via_smtp(email):
    """SMTP 서버를 통해 이메일 전송 (내부 함수)"""
    # 실제로는 smtplib 등 사용
    print(f"[SMTP] 이메일 전송: {email['subject']}")
    return True

def _cancel_smtp_queue(email_id):
    print(f"[SMTP] 큐에서 제거: {email_id}")

def _export_to_storage(data):
    return "https://storage.example.com/exports/20260310.json"

9.3.1 부수 효과 — 표준 vs 사용자 메서드의 가장 큰 차이

핵심 개념

표준 메서드: 부수 효과 금지! ❌
  - Create → 리소스 생성만 해야 함
  - Update → 리소스 수정만 해야 함
  - Delete → 리소스 삭제만 해야 함
  - 다른 시스템과 통신하거나 추가 동작을 하면 안 됨

사용자 메서드: 부수 효과 허용! ✅
  - 이메일 보내기, 백그라운드 작업 트리거, 여러 리소스 업데이트...
  - 무엇이든 가능하다. 대신 명확히 문서화해야 한다.

비유: 은행 창구

표준 메서드 = 일반 창구 (입금, 출금, 잔액 조회) → 정해진 업무만 처리. "이체하면서 보험 가입" 같은 건 안 됨

사용자 메서드 = VIP 상담 창구 → 복합 업무 가능. "해외 송금하면서 환율 우대 적용하고 알림 발송" → 무엇을 하는지 정확히 설명해야 함

이메일 전송의 올바른 분리

Step 1: CreateEmail (표준 메서드) — 깔끔하게 저장만
┌──────────────────────────────────────────┐
│  사용자 → API 서버 → 스토리지            │
│                                           │
│  CreateEmail({subject: "회의", ...})      │
│  → DB에 초안으로 저장                     │
│  → Email { state: "draft" } 반환          │
│                                           │
│  부수 효과: 없음 ✅                       │
└──────────────────────────────────────────┘

Step 2: SendEmail (사용자 메서드) — 복잡한 작업 수행
┌──────────────────────────────────────────┐
│  사용자 → API 서버 → SMTP → 스토리지     │
│                                           │
│  SendEmail({id: 1})                       │
│  → SMTP 서버에 이메일 전송 요청           │
│  → 성공하면 상태를 "sent"로 업데이트      │
│  → Email { state: "sent" } 반환           │
│                                           │
│  부수 효과: SMTP 통신, 상태 변경 ✅       │
└──────────────────────────────────────────┘

시퀀스 다이어그램

   사용자          API 서버         스토리지        SMTP 서버
     │                │                │               │
     │                │                │               │
     │ ── Step 1: 이메일 리소스 생성 ──────────────── │
     │                │                │               │
     │ CreateEmail()  │                │               │
     │───────────────>│                │               │
     │                │  이메일 저장    │               │
     │                │───────────────>│               │
     │                │      OK        │               │
     │                │<───────────────│               │
     │ {state:"draft"}│                │               │
     │<───────────────│                │               │
     │                │                │               │
     │         ── 잠시 후 ──           │               │
     │                │                │               │
     │ ── Step 2: 이메일 전송 ──────────────────────── │
     │                │                │               │
     │ SendEmail()    │                │               │
     │───────────────>│                │               │
     │                │          SMTP로 전송           │
     │                │───────────────────────────────>│
     │                │                │       200 OK  │
     │                │<───────────────────────────────│
     │                │  보냄 상태 표시 │               │
     │                │───────────────>│               │
     │                │      OK        │               │
     │                │<───────────────│               │
     │ {state:"sent"} │                │               │
     │<───────────────│                │               │

9.3.2 리소스 vs 컬렉션 — 대상에 따른 URL 구조

표준 메서드의 대상 패턴

메서드 대상 HTTP 요청
CreateEmail 부모 컬렉션 POST /users/1/emails
ListEmails 부모 컬렉션 GET /users/1/emails
GetEmail 리소스 GET /users/1/emails/2
UpdateEmail 리소스 PATCH /users/1/emails/2
DeleteEmail 리소스 DELETE /users/1/emails/2

사용자 메서드의 대상 결정 가이드

질문: "이 동작은 하나의 리소스에 대한 것인가, 여러 리소스에 대한 것인가?"

┌─────────────────────────────────────────────────┐
│  하나의 이메일을 보낸다                          │
│  → 리소스 대상: POST /users/1/emails/2:send     │
│                                                  │
│  모든 이메일을 내보낸다                          │
│  → 컬렉션 대상: POST /users/1/emails:export     │
│                                                  │
│  사용자의 전체 정보를 내보낸다                    │
│  → 부모 리소스 대상: POST /users/1:export        │
│     (이메일뿐 아니라 사용자의 모든 데이터)       │
└─────────────────────────────────────────────────┘

여러 부모에 걸친 리소스 처리

"여러 사용자의 이메일을 보관하고 싶다면?"

예: users/1/emails/2 와 users/2/emails/4를 동시에 보관

방법: 와일드카드(-) 사용
POST /users/-/emails:archive

요청 본문에 보관할 이메일 ID 목록 포함:
{
    "emailIds": [
        "users/1/emails/2",
        "users/2/emails/4"
    ]
}

하이픈(-)이 "모든 사용자"를 의미하는 와일드카드 역할

비유: 아파트 관리 사무소

리소스 대상 = "301호 택배 전달해 주세요" (한 세대) 컬렉션 대상 = "3층 전체 소독해 주세요" (한 층 전체) 와일드카드 = "전체 아파트 수도 점검해 주세요" (모든 층)

Flask 실무 예제 — 리소스 vs 컬렉션 대상

# 리소스 대상 사용자 메서드
@app.route("/users/<user_id>/emails/<email_id>:send", methods=["POST"])
def send_email(user_id, email_id):
    """단일 이메일 전송"""
    email = get_email_or_404(user_id, email_id)
    smtp_send(email)
    email["state"] = "sent"
    return jsonify(email)


# 컬렉션 대상 사용자 메서드
@app.route("/users/<user_id>/emails:export", methods=["POST"])
def export_emails(user_id):
    """사용자의 전체 이메일 내보내기"""
    all_emails = get_user_emails(user_id)
    url = save_to_cloud_storage(all_emails)
    return jsonify({"exportUrl": url, "count": len(all_emails)})


# 부모 리소스 대상 사용자 메서드 (사용자 전체 데이터)
@app.route("/users/<user_id>:export", methods=["POST"])
def export_user(user_id):
    """사용자의 모든 데이터 내보내기 (이메일 포함)"""
    user_data = get_all_user_data(user_id)
    url = save_to_cloud_storage(user_data)
    return jsonify({"exportUrl": url})

9.3.3 무상태 사용자 메서드 — 저장하지 않는 메서드

핵심 개념

유상태 메서드 (Stateful):
  - 서버에 데이터를 저장하거나 변경함
  - 예: SendEmail → DB에 상태 저장, SMTP로 전송

무상태 메서드 (Stateless):
  - 서버에 아무것도 저장하지 않음
  - 입력을 받아 처리 결과만 반환
  - 예: 번역, 유효성 검증, 계산

비유: 계산기 vs 금고

무상태 = 계산기: 숫자를 넣으면 답을 알려주지만, 이전 계산을 기억하지 않는다 유상태 = 금고: 물건을 넣으면 저장하고, 나중에 다시 꺼낼 수 있다

번역 API 예제 — 순수 무상태

abstract class TranslationApi {
    @post("/text:translate")
    TranslateText(req: TranslateTextRequest): TranslateTextResponse;
}

interface TranslateTextRequest {
    sourceLanguageCode: string;    // "ko"
    targetLanguageCode: string;    // "en"
    text: string;                  // "안녕하세요"
}

interface TranslateTextResponse {
    text: string;                  // "Hello"
}
흐름:
  사용자 → "안녕하세요를 영어로 번역해 줘" → API → "Hello" → 사용자

  서버에 저장되는 것: 없음!
  DB 통신: 없음!
  나중에 다시 요청하면: 또 새로 번역함

GDPR과 무상태 메서드의 관계

GDPR (General Data Protection Regulation):
유럽 개인정보보호법 — 데이터가 어디에 어떻게 저장되는지 엄격히 규제

┌─────────────────────────────────────────────────┐
│  번역 API가 사용자 텍스트를 DB에 저장한다면?     │
│  → GDPR 위반 가능! 개인정보 보관 규정 준수 필요  │
│                                                  │
│  무상태 메서드로 만들면?                         │
│  → 데이터를 저장하지 않으므로 GDPR 부담 감소     │
│  → "처리만 하고 버린다" = 개인정보 보호에 유리   │
└─────────────────────────────────────────────────┘

무상태 메서드에 앵커 리소스 연결하기

순수 무상태도 좋지만, 비용 추적이나 권한 관리가 필요하면?

// 방법 1: 순수 무상태 — 앵커 없음
@post("/text:translate")
TranslateText(req): TranslateTextResponse;
// 문제: 누가 얼마나 번역했는지 추적 불가

// 방법 2: 앵커 리소스 (프로젝트)에 연결
@post("/{parent=projects/*}/text:translate")
TranslateText(req): TranslateTextResponse;
// 장점: 프로젝트별 비용 청구, 사용량 추적 가능

// 방법 3: 번역 모델 리소스에 연결 (미래 확장성)
@post("/translationModels")
CreateTranslationModel(req): TranslationModel;
// ... 표준 메서드들

@post("/{id=translationModels/*}/text:translate")
TranslateText(req): TranslateTextResponse;
// 장점: 모델별 번역 결과 차이 지원 (ML 모델 A vs B)

Flask 실무 예제 — 무상태 이메일 주소 검증

@app.route("/emailAddress:validate", methods=["POST"])
def validate_email_address():
    """무상태 사용자 메서드: 이메일 주소 유효성 검증

    - 어떤 리소스에도 종속되지 않음
    - DB에 아무것도 저장하지 않음
    - 입력 → 검증 결과 반환
    """
    data = request.json
    address = data.get("emailAddress", "")

    is_valid = bool(re.match(r"^[\w.+-]+@[\w-]+\.[\w.]+$", address))
    suggestions = []
    if not is_valid:
        suggestions.append("이메일 형식을 확인해 주세요 (예: [email protected])")

    return jsonify({
        "emailAddress": address,
        "isValid": is_valid,
        "suggestions": suggestions,
    })


# 앵커 리소스에 연결된 무상태 메서드
@app.route("/projects/<project_id>/text:translate", methods=["POST"])
def translate_text(project_id):
    """무상태 사용자 메서드: 텍스트 번역 (프로젝트별 비용 추적)

    프로젝트 리소스에 연결되어 있지만
    메서드 자체는 데이터를 저장하지 않는다.
    비용 추적과 권한 관리를 위한 앵커 역할만 한다.
    """
    data = request.json
    source_lang = data.get("sourceLanguageCode")
    target_lang = data.get("targetLanguageCode")
    text = data.get("text")

    # 번역 수행 (외부 API 또는 ML 모델)
    translated = _translate(text, source_lang, target_lang)

    # 비용 추적 (프로젝트별)
    _record_usage(project_id, len(text))

    return jsonify({"text": translated})

9.3.4 최종 API 정의 — 표준 + 사용자 메서드 조합

Email API 완전한 설계

abstract class EmailApi {
    static version = "v1";
    static title = "Email API";

    // ─── 표준 메서드 ───
    // CreateEmail, GetEmail, UpdateEmail, DeleteEmail, ListEmails
    // ... (CRUD)

    // ─── 사용자 메서드 (리소스 대상) ───

    // 이메일 전송: 상태 전환 + SMTP 통신
    @post("/{id=users/*/emails/*}:send")
    SendEmail(req: SendEmailRequest): Email;

    // 전송 취소: 지연 전송 단계에서 철회
    @post("/{id=users/*/emails/*}:unsend")
    UnsendEmail(req: UnsendEmailRequest): Email;

    // 삭제 취소: 논리 삭제된 이메일 복구
    @post("/{id=users/*/emails/*}:undelete")
    UndeleteEmail(req: UndeleteEmailRequest): Email;

    // ─── 사용자 메서드 (컬렉션 대상) ───

    // 전체 내보내기: 원격 스토리지에 저장
    @post("/{parent=users/*}/emails:export")
    ExportEmails(req: ExportEmailsRequest): ExportEmailsResponse;

    // ─── 사용자 메서드 (무상태, 리소스 무관) ───

    // 이메일 주소 검증: 저장 없이 결과만 반환
    @post("/emailAddress:validate")
    ValidateEmailAddress(req: ValidateEmailAddressRequest):
        ValidateEmailAddressResponse;
}

시각적 요약

Email API v1 전체 구조
========================

표준 메서드 (CRUD)          사용자 메서드 (Custom)
┌──────────────────┐       ┌────────────────────────┐
│ CreateEmail      │       │ :send      (리소스)     │ ← 이메일 전송
│ GetEmail         │       │ :unsend    (리소스)     │ ← 전송 취소
│ UpdateEmail      │       │ :undelete  (리소스)     │ ← 삭제 복구
│ DeleteEmail      │       │ :export    (컬렉션)     │ ← 전체 내보내기
│ ListEmails       │       │ :validate  (무상태)     │ ← 주소 검증
└──────────────────┘       └────────────────────────┘
       │                           │
       v                           v
  데이터 CRUD만 수행          부수 효과 허용
  부수 효과 없음              자유로운 동작

9.4 트레이드오프 — 사용자 메서드의 양날의 검

REST 원칙과의 긴장

REST의 핵심: 모든 것은 "리소스"이다.
  → 모든 동작은 리소스에 대한 CRUD로 표현해야 한다.

사용자 메서드는 이 원칙을 벗어난다.
  → 사실상 RPC(원격 프로시저 호출) 스타일
  → REST와 RPC의 중간지대

남용의 위험

사용자 메서드를 남용하는 나쁜 징후:

1. 모든 동작이 사용자 메서드일 때 ❌
   POST /users/1:getName          ← GetUser 표준 메서드로 충분
   POST /users/1:changeName       ← UpdateUser 표준 메서드로 충분
   POST /users:getAll             ← ListUsers 표준 메서드로 충분

2. 리소스 레이아웃이 잘못된 징후 ❌
   POST /orders/1:addItem         ← OrderItem 리소스를 만들어야 함
   POST /orders/1:removeItem      ← DeleteOrderItem으로 해결 가능
   → 리소스 설계를 다시 해야 하는데 사용자 메서드로 떼우고 있음

3. 올바른 사용 예 ✅
   POST /emails/1:send            ← 상태 전환 + 외부 통신 (표준으로 불가)
   POST /documents/1:translate    ← 계산 작업 (표준과 성격이 다름)
   POST /users/1/emails:export    ← 일괄 처리 (단일 CRUD로 불가)

비유: 청테이프 vs 제대로 된 수리

사용자 메서드를 남용하면 = 파이프가 샜는데 청테이프로 감는 것 → 당장은 물이 안 새지만, 근본적 문제(리소스 설계)를 해결하지 못함

올바른 접근 = 파이프를 교체하는 것 → 리소스 구조를 재설계하고, 진짜 필요한 곳에만 사용자 메서드 사용

사용자 메서드 도입 체크리스트

사용자 메서드를 만들기 전 자문할 것:

□ 표준 메서드(CRUD)로 정말 해결할 수 없는가?
□ 리소스 구조를 재설계하면 표준 메서드로 가능하지 않은가?
□ 부수 효과가 반드시 필요한 동작인가?
□ API 전체에서 일관된 네이밍과 패턴을 따르고 있는가?
□ 사용자 메서드가 너무 많아지고 있지는 않은가?
  → 리소스 설계에 문제가 있다는 신호일 수 있다!

9.5 연습문제 풀이

문제 1: 리소스를 생성함으로써 부수 효과가 발생해야 한다면?

"리소스를 생성함으로써 부수 효과가 발생해야 한다면 어떨까? 사용자 메서드를 생성하면 이 문제를 해결할 수 있을까? 그 이유는 무엇인가?"

풀이:

상황: 주문(Order) 리소스를 생성하면
      → 결제 처리 + 재고 감소 + 알림 발송이 필요

표준 메서드(CreateOrder)로 처리하면:
┌──────────────────────────────────────┐
│ CreateOrder → DB 저장              │
│             + PG사 결제 요청        │ ← 부수 효과!
│             + 재고 DB 업데이트      │ ← 부수 효과!
│             + 판매자 알림 발송      │ ← 부수 효과!
│                                      │
│ 문제: Create 메서드의 의미를 왜곡    │
│       "리소스 생성"인데 실제론 복합 작업│
└──────────────────────────────────────┘

사용자 메서드로 분리:
┌──────────────────────────────────────┐
│ Step 1: CreateOrder (표준 메서드)     │
│   → 주문 리소스 생성 (state: "draft")│
│   → DB 저장만 수행. 부수 효과 없음   │
│                                      │
│ Step 2: PlaceOrder (사용자 메서드)    │
│   POST /orders/123:place             │
│   → 결제 처리                        │
│   → 재고 감소                        │
│   → 알림 발송                        │
│   → 상태를 "confirmed"으로 변경      │
└──────────────────────────────────────┘

핵심 이유:
- 표준 메서드는 "정의된 동작만 수행"이라는 원칙이 있다
- Create는 "생성만", Update는 "수정만" 해야 한다
- 부수 효과가 필요하면 사용자 메서드로 분리하여
  "이 메서드는 부수 효과가 있다"는 것을 명시적으로 알린다

문제 2: 사용자 메서드가 컬렉션을 대상으로 해야 될 경우는?

"사용자 메서드가 컬렉션을 대상으로 해야 될 경우는 언제인가? 부모 리소스에 대해서도 생각해보라."

풀이:

1. 컬렉션 대상이 적합한 경우:
   "동일한 컬렉션 내의 여러 리소스를 다뤄야 할 때"

   예시들:
   POST /users/1/emails:export       ← 사용자1의 모든 이메일 내보내기
   POST /users/1/emails:archiveAll   ← 사용자1의 모든 이메일 보관
   POST /users/1/emails:markAllRead  ← 사용자1의 모든 이메일 읽음 처리

2. 부모 리소스 대상이 적합한 경우:
   "자식 컬렉션을 넘어 부모 리소스 전체에 관련된 작업"

   예시들:
   POST /users/1:export              ← 사용자의 모든 데이터 내보내기
                                       (이메일 + 연락처 + 설정 등)
   POST /users/1:deactivate          ← 사용자 계정 비활성화
                                       (모든 하위 리소스에 영향)

3. 와일드카드 부모가 필요한 경우:
   "여러 부모에 걸친 리소스를 한번에 처리"

   POST /users/-/emails:archive      ← 모든 사용자의 특정 이메일들 보관
   요청 본문에 대상 이메일 ID 목록 포함

판단 기준:
┌──────────────────────────────────────────┐
│  "한 리소스에 대한 동작?" → 리소스 대상   │
│  "같은 종류 여럿?"       → 컬렉션 대상   │
│  "부모 전체에 영향?"     → 부모 리소스    │
│  "부모가 여럿?"          → 와일드카드     │
└──────────────────────────────────────────┘

문제 3: 무상태 사용자 메서드에 전적으로 의존하면 안 되는 이유는?

"무상태 사용자 메서드에 전적으로 의존하면 안 되는 이유는 무엇인가?"

풀이:

무상태 메서드의 한계:

1. 비용 추적 불가
   ┌──────────────────────────────────────┐
   │ POST /text:translate                 │
   │ → 누가 호출했는지 모름               │
   │ → 프로젝트별 비용 청구 불가          │
   │ → API 키만으로 사용량 관리가 어려움  │
   └──────────────────────────────────────┘

2. 확장성 부족
   ┌──────────────────────────────────────┐
   │ 처음: "텍스트 번역" 하나만 필요      │
   │ 나중: 여러 번역 모델 중 선택 필요    │
   │ → 무상태면 모델 선택 기능 추가 어려움│
   │ → TranslationModel 리소스 필요       │
   └──────────────────────────────────────┘

3. 권한 관리 어려움
   ┌──────────────────────────────────────┐
   │ "팀 A는 번역 허용, 팀 B는 금지"     │
   │ → 리소스가 없으면 권한을 어디에 설정?│
   │ → 앵커 리소스가 있어야 IAM 적용 가능 │
   └──────────────────────────────────────┘

4. 감사/로깅 어려움
   ┌──────────────────────────────────────┐
   │ "지난달에 누가 번역을 몇 번 했지?"   │
   │ → 무상태면 기록이 남지 않음          │
   │ → 앵커 리소스에 연결해야 추적 가능   │
   └──────────────────────────────────────┘

결론: 순수 무상태는 간단하지만 장기적으로 한계가 있다.
      미래에 상태 기반(리소스 연결)이 필요할 수 있으므로
      처음부터 앵커 리소스를 고려하는 것이 현명하다.
      단, 당장 필요 없는 기능을 과도하게 미리 만들 필요는 없다.

요약

  • 사용자 메서드는 거의 대부분 HTTP POST 메서드를 사용하며, PATCH 메서드는 사용하지 않는다. 사용자 메서드가 멱등성을 띠고 안전하다면 GET 메서드를 사용할 수도 있다.
  • 사용자 메서드는 콜론(:) 문자를 사용해 목표 리소스와 수행할 동작을 분리한다 (예: /missiles/1234:launch).
  • 표준 메서드에서는 부수 효과가 금지돼 있는 반면, 사용자 메서드에서는 허용된다. 다만 사용자가 혼동하지 않게, 필요한 부분에만 사용하고 이 사실을 명확히 문서화해야 한다.
  • 하나의 컬렉션에 여러 리소스가 포함돼 있을 경우 일반적으로 사용자 메서드는 컬렉션을 대상으로 해야 한다.
  • 특히 계산량이 많은 작업의 경우 API가 무상태 사용자 메서드를 통해 대량의 작업을 수행해야 할 수 있다. 이러한 전략을 사용하면 나중에 상태 기반성이 중요하고 까다로운 문제가 될 수 있으므로 신중하게 결정해야 한다.

부록 A — 용어 정리

용어 영어 설명
사용자 메서드 Custom Method 표준 CRUD를 벗어나는 API 호출
표준 메서드 Standard Method CRUD에 해당하는 기본 메서드
부수 효과 Side Effect 주 작업 외 추가 발생 동작
무상태 Stateless 서버에 데이터를 저장하지 않음
멱등성 Idempotency 같은 요청 반복해도 결과 동일
앵커 리소스 Anchor Resource 무상태 메서드를 연결하는 부모 리소스
하류 의존성 Downstream Dependency 내부적으로 의존하는 외부 시스템
콜론 구분자 Colon Separator URL에서 리소스와 동작을 분리하는 ':'

부록 B — 사용자 메서드 결정 플로차트

시작: "이 동작을 API로 제공해야 한다"
  │
  ├─ 표준 CRUD로 표현 가능?
  │    ├─ 예 → 표준 메서드 사용 (끝)
  │    └─ 아니오 ↓
  │
  ├─ 리소스를 재설계하면 표준으로 가능?
  │    ├─ 예 + 합리적 → 리소스 재설계 후 표준 메서드 (끝)
  │    ├─ 예 + 과도한 변형 필요 → 사용자 메서드 사용 ↓
  │    └─ 아니오 → 사용자 메서드 사용 ↓
  │
  ├─ 부수 효과가 필요?
  │    ├─ 예 → 유상태 사용자 메서드
  │    └─ 아니오 ↓
  │
  ├─ 서버에 데이터를 저장?
  │    ├─ 예 → 유상태 사용자 메서드
  │    └─ 아니오 → 무상태 사용자 메서드
  │                  └─ 비용 추적 필요? → 앵커 리소스 연결
  │
  └─ 대상 결정:
       ├─ 단일 리소스 → 리소스 대상 (POST /resource/id:action)
       ├─ 같은 컬렉션 여럿 → 컬렉션 대상 (POST /collection:action)
       └─ 리소스 무관 → 무상태 (POST /resource:action)

부록 C — 실전 사용자 메서드 패턴 모음

이메일 서비스:
  POST /emails/123:send          이메일 전송
  POST /emails/123:unsend        전송 취소
  POST /emails/123:archive       보관
  POST /emails/123:undelete      삭제 복구
  POST /emails:export            전체 내보내기
  POST /emailAddress:validate    주소 검증

결제 서비스:
  POST /payments/456:refund      환불 처리
  POST /payments/456:capture     결제 확정
  POST /payments:reconcile       전체 정산

문서 서비스:
  POST /documents/789:publish    문서 게시
  POST /documents/789:translate  문서 번역
  POST /documents/789:share      공유 링크 생성
  POST /documents:search         전문 검색

사용자 서비스:
  POST /users/1:deactivate       계정 비활성화
  POST /users/1:resetPassword    비밀번호 초기화
  POST /users/1:export           개인정보 내보내기 (GDPR)
난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플